C'est quoi docker

Docker est un outil qui peut empaqueter une application et ses dépendances dans un conteneur isolé, qui pourra être exécuté sur n'importe quel serveur (source Wikipedia)

Docker permet de la conteneurisation d'applications. Vous connaissiez la virtualisation, c'est donc une autre manière d'isoler son application sur un serveur afin de la rendre standard d’un environnement à l’autre.

En virtualisation, on isole jusqu'au système d'exploitation sur lequel tourne notre application. En conteneurisation, l'OS n'est pas inclus dans le processus d'isolation, mais docker utilise les fonctionnalités communes du système d'exploitation de l'hôte, ce qui rend les conteneurs très légers par rapport à des machines virtuelles :

ConteneurisationConteneurisation
 

Pourquoi docker

Docker permet facilement de lancer un environnement isolé sur sa machine de manière beaucoup plus légère qu'en virtualisation et sans installer (sur la machine hôte) de dépendances liées directement à l'application cible.

Docker permet également au développeur d'avoir un environnement ISO PROD même avec des environnements en production très différents d'une application à l'autre.

Attention, docker ne permet pas de s’affranchir de la sécurité autant au niveau de l’hôte sur lequel tourne le service ou même de ses propres conteneurs. Pour ce qui est de l’hôte, un hôte compromis donnera accès à l’attaquant à l’ensemble des conteneurs tournant sur cette machine lui permettant ainsi de stopper ou pire supprimer des volumes et ainsi perdre des données. D’un point de vue conteneur, il faut bien connaître les images que l’on utilise et les mettre à jour régulièrement pour un maximum de sécurité (exemple avec cette récente vulnérabilité dans l’image alpine docker largement répandu)

Par exemple, comment développer sur une machine pour deux projets dont les versions de PHP sont très différentes (5.6 & 7.0 par exemple):

La dernière solution sera évidemment celle à privilégier. Grâce à docker, l'approche micro-service est d'autant plus favorisée (pour plus d’explication sur les microservices ). En effet, quand on architecture une stack docker, on sépare au maximum les services jusqu'à obtenir un conteneur pour un service. Etudions un cas concret.

Prêt ? C'est parti.

Mon premier conteneur

Lancement

On lance notre premier conteneur grâce à la commande docker run

$ docker run nginx

Et c’est tout, à partir de là docker va télécharger l'image nginx et lancer le conteneur. Prenez maintenant votre navigateur préféré, et tapez localhost, ça ne fonctionne pas et c’est normal. Vous avez lancé un service HTTP en écoute sur le port 80 à l’intérieur de votre conteneur, mais à aucun moment vous n’avez ouvert un accès vers l’extérieur. On va donc exposer un port entre notre machine et le conteneur docker grâce à l’option expose.

Mapper les ports

On mappe donc le port 80 de notre machine sur le port 80 de notre conteneur docker:

$ docker run --expose 80:80 nginx

Reprenez votre navigateur, F5, et cette fois vous avez bien la page web de bienvenue de nginx qui s’affiche.

Vous pouvez mapper n’importe quel port de votre machine sur le port interne du conteneur nginx.

Dans la syntaxe --expose 80:80, le premier port 80 correspond à celui de la machine hôte et le second au port interne du conteneur, mais on aurait pu mapper le port 85 de notre machine sur le port 80 de notre conteneur en faisant :

$ docker run --expose 85:80 nginx

Mapper les répertoires

Nous sommes maintenant capable de lancer des conteneurs sur notre machine, mais ce serait encore mieux de pouvoir afficher nos propres fichiers HTML !

Pour cela l’option volume permet de mapper un répertoire local depuis la machine hôte sur un répertoire à l’intérieur de notre conteneur :

$ docker run --expose 80:80 --volume /home/bigint/nginx:/usr/share/nginx/html nginx

Et voilà, le répertoire /usr/share/nginx/html à l’intérieur de notre conteneur contient bien nos fichiers sources situés dans /home/bigint/nginx (et non plus les fichiers nginx par défaut).

On utilise en partie ce système pour utiliser son propre fichier de configuration et ainsi écraser celui par défaut, exemple :

$ docker run --expose 80:80 --volume /home/bigint/nginx_conf/my_default.conf:/etc/nginx/conf.d/default.conf nginx

Maintenant, prenons l’exemple d’un conteneur mysql. On sait mapper le port de mysql mais quel sera alors le mot de passe root puisque celui-ci est spécifié à l’installation.

Docker nous permet de renseigner des variables d’environnement nous permettant de dynamiser la configuration de chacun de nos conteneurs.

Les variables d’environnement

Lançons notre conteneur pour mysql :

$ docker run --expose 3306:3306 mariadb
error: database is uninitialized and password option is not specified
You need to specify one of MYSQL_ROOT_PASSWORD, MYSQL_ALLOW_EMPTY_PASSWORD and MYSQL_RANDOM_ROOT_PASSWORD

Là, il n’est pas content. En effet, le conteneur mysql requiert au moins une variable d'environnement lui permettant d’initialiser le mot de passe root. Grâce au variable d’environnement, vous allez pouvoir changer les mots de passes ou encore inclure une partie de fichiers de configuration spécifiques à chaque conteneur.

Dans un environnement de production, ces variables pourraient être injectées depuis un coffre-fort (comme les objets secrets sur kubernetes).

On spécifie notre mot de passe root via la variable d'environnement grâce au paramètre env:

$ docker run --port 3306:3306 --env MYSQL_ROOT_PASSWORD=mon_super_mot_de_passe mariadb
# Après une courte minute, notre serveur mysql est prêt à l’emploi, on peut tenter de s’y connecter
$ mysql -h 127.0.0.1 -u root -p
Enter password: (mon_super_mot_de_passe)
MariaDB [(none)]>

La connexion au serveur mysql est établie.

Les créateurs de l'image docker mariadb ont mis à disposition d’autres variables d’environnement permettant de configurer le serveur au premier lancement comme :

Pour charger une base de données la première fois (à partir d’un dump sql par exemple), vous avez possibilité d’ajouter vos propres scripts sql dans le dossier /docker-entrypoint-initdb.d. Au démarrage, le script de démarrage va initialiser le serveur mysql (comptes...) puis va exécuter l’ensemble des scripts présents dans le dossier /docker-entrypoint-initdb.d s’ils existent (par ordre alphabétique).

Cette méthode permet de configurer un même service différemment d'un conteneur à l'autre. On l'utilise dans la plupart des images (elasticsearch, wordpress...).

Debug

Si quelque chose se passe mal lors du lancement du conteneur, on aura pour réflexe d'accéder à ses logs pour obtenir le message d'erreur et ainsi résoudre le problème.

En dehors du conteneur

À partir de la machine hôte, on peut accéder aux logs du conteneur par la commande docker log:

$ docker log ID_CONTENEUR

La commande docker log affiche simplement la sortie standard du conteneur. Pour écrire dans la sortie standard, il suffit au créateur d'image docker de rediriger un fichier vers la sortie /dev/stdout et /dev/stderr, ce sont souvent les fichiers associés au service du conteneur, par exemple dans un conteneur nginx:

root@4bb660eb5864:/var/log/nginx# ls -lh
total 0
lrwxrwxrwx 1 root root 11 Mar 27 2017 access.log -> /dev/stdout
lrwxrwxrwx 1 root root 11 Mar 27 2017 error.log -> /dev/stderr

À l'intérieur du conteneur

Parfois, on a besoin de vérifier ou modifier le contenu d'un fichier directement à l'intérieur du conteneur, par exemple pour tester un fichier de configuration rapidement. C'est la commande docker exec qui nous permettra de le faire, accompagnée des options interactive et tty. On peut avoir un shell exécutant bash de cette manière:

$ docker exec --interactive --tty ID_CONTENEUR bash

ID_CONTENEUR est bien sûr à remplacer par l'id généré par docker pour ce conteneur, pour récupérer la liste des conteneurs avec leur nom et identifiant:

$ docker ps

Note: Par défaut un nom est généré pour chaque conteneur (en prenant soin qu’il ne s’appelle jamais boring_wozniak parce qu'ils ont de l'humour chez docker )

Dans nos exemples, nous avons lancé nos conteneurs en avant plan, ce qui nous permet de voir les logs du service de notre conteneur mais ne rend pas la main à notre shell tant que le docker est lancé. Nous aurions pu lancer nos conteneurs en arrière-plan grâce à l’option detach puis accéder au log grâce à la commande docker logs :

$ docker run --detach --name=mysql --port 3306:3306 --env MYSQL_ROOT_PASSWORD=mon_super_mot_de_passe mariadb
$ docker logs ID_CONTENEUR

Les images

Pour faire sa propre image docker, la première chose à connaître est de choisir un conteneur parent sur lequel notre image se base. En effet, la plupart du temps on aura plus facile de forker une image et ajouter notre partie comme dans l’ajout d’une extension PHP par exemple.

Une image docker est la résultante d’un build.

Un build est l’exécution d’une ou plusieurs instructions (depuis un Dockerfile) à l'intérieur d'un conteneur docker.

Ces images peuvent ensuite être publiées sur un registry, le plus connu étant le Docker Hub mais il en existe d’autres et vous pouvez avoir votre propre registry privé (qui peut être lui-même une image docker ).

Lorsqu’une image est publiée, elle est généralement taguée (par un incrément de version par exemple). Cela permet de geler une image pour une version spécifique dans le temps.

Depuis le début de l'article, aucun tag n’a été utilisé lorsque les conteneurs ont été lancés et c'est vivement déconseillé.

Par défaut, lorsqu’aucun tag n’est spécifié, docker utilise le tag virtuel latest, qui signifie :

Télécharge la dernière image qui a été publiée sur la registry dont aucun tag n’a été spécifié.

On comprend alors que ce n’est pas forcément la dernière version du service mais bien la dernière version publiée sans tag.

C’est pourquoi il est très important de toujours utiliser des images taguées. De plus, si vous ne taguez pas les images, vous n'avez aucune garantie que l'image que vous utilisez ait le même comportement au fur à mesure des mises à jour apportées à l’image dans le temps..Vous êtes maintenant capable de démarrer un conteneur. Mais comme votre projet à a été architecturé en micro-service, vous allez avoir besoin de faire communiquer plusieurs conteneurs entre eux.

Interconnexion des conteneurs

Dans la suite des exemples, on va utiliser un conteneur nginx pour servir les requêtes HTTP et un conteneur PHP-FPM pour exécuter nos scripts PHP. Notre fichier de configuration nginx volontairement simplifié ressemble à cela :

server {
root /var/www/html;
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php:9000; # <-- la ligne qui nous intéresse ici
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}

La ligne la plus intéressante ici est le fastcgi_pass. Habituellement quand les services HTTP et PHP sont sur la même machine, on met 127.0.0.1:9000 : cela signifie que le service PHP est en écoute sur le port 9000 et prêt à exécuter des scripts PHP. Dans notre cas, cela correspondra à deux conteneurs. En tant que bonne pratique docker, un conteneur = un but ( pour plus d’explication ). Parfois, il sera tout de même intéressant d’avoir plusieurs services dans le même container, par exemple dans le cas de la gestion d’une crontab ou l’utilisation d’une application embarquée avec une partie des services comme l’image wordpress qui embarque apache et PHP. Dans ce dernier cas vous devrez utiliser un superviseur de processus comme supervisor pour relancer un service s’il crache car le conteneur continuera de tourner.

On spécifie donc le serveur PHP-FPM grâce au nom d’hôte. On lance le serveur nginx:

$ docker run -v $PWD/my_nginx.conf:/etc/nginx/conf.d/default.conf nginx 2019/04/19 13:26:44 [emerg] 1#1: host not found in upstream "php" in /etc/nginx/conf.d/default.conf:7 nginx: [emerg] host not found in upstream "php" in /etc/nginx/conf.d/default.conf:7

(Au passage, la variable d'environnement unix PWD peut être utilisé pour spécifier le répertoire courant)

Au lancement, le conteneur nginx s’arrête immédiatement car il ne connaît pas la machine php.

2019/04/18 14:51:38 [emerg] 1#1: host not found in upstream "php" in /etc/nginx/conf.d/default.conf:5

Pour que cela fonctionne, il faut que le conteneur nginx puisse communiquer avec le conteneur php par son nom (php). Pour cela docker propose l’isolation par le réseau. Il est possible de créer un réseau et d’affecter un conteneur à un (ou plusieurs) réseau. Tous les conteneurs à l’intérieur d’un même réseau peuvent communiquer entre eux par leur nom d’hôte ou leur ip. C’est parti :

docker network create bigint docker run --network="bigint" --name=php --volume $PWD/php:/var/www/html php:7.3.4-fpm docker run --network="bigint" --name=nginx --volume $PWD/my_nginx.conf:/etc/nginx/conf.d/default.conf --volume $PWD/php:/var/www/html nginx

Attention à bien mapper les fichiers web sur le conteneur nginx et sur le conteneur PHP. Et voilà, nos fichiers PHP s’exécutent correctement sur une version 7.3.4. Vous n’avez plus qu’à éteindre puis relancer le conteneur PHP en version 7.0 (ou 5.4 au besoin) pour valider la compatibilité de vos scripts sous une version de PHP plus ancienne.

C’est une bonne chose. Mais lancer ces commandes pour faire communiquer deux conteneurs, c’est tout de même laborieux et peu esthétique. Et quand vous en aurez 5 (ce qui peut arriver très vite avec un proxy, un service de cache et une base de données), vous ne vous y retrouverez plus et ce sera vite le désordre.

Docker-compose

Pour cela, docker met à disposition un outil appelé docker-compose. C’est un fichier yaml, dans lequel on décrit les services et leur configuration. Un fichier docker-compose contient (en partie) les informations suivantes :

Voici un exemple simple de fichier docker-compose (ici NGINX / PHP-FPM / MYSQL / REDIS):

version: '3.7'

services:

http:
image: nginx:1.10.3
volumes:
- ./www:/usr/src/www
- ./my_default.conf:/etc/nginx/conf.d/default.conf
ports:
- 80:80

php:
image: php:7.3.4-fpm
volumes:
- ./www:/usr/src/www

mysql:
image: mariadb:10.1.26
environment:
MYSQL_ROOT_PASSWORD: mon_super_mot_de_passe

redis:
image: redis:4

On démarre l'ensemble des services décrits dans le fichier par la commande:

$ docker-compose up

Lorsqu’on démarre une stack de services, par défaut docker créé un réseau nommé default, préfixé par le nom du répertoire dans lequel se trouve le fichier docker-compose. Par exemple :

`monprojet_default`

Grâce à ce mécanisme, tous les services d’un même fichier docker-compose peuvent communiquer entre eux par leur hostname. C’est la manière la plus simple et courante de faire communiquer deux conteneurs. L’isolation est perfectible puisqu’on pourrait isoler les couples de conteneurs ayant besoin de communiquer entre eux par exemple:

NGINX => PHP PHP => MYSQL PHP => REDIS

Proxy

Grâce à notre fichier YAML, nous avons une manière simple et élégante de décrire les services de notre projet. Mais si on veut faire de même sur un autre projet, il faudra modifier les ports pour utiliser des ports libres (81, 82, 83...). On imagine bien la difficulté à connaître par avance un port libre.

Pour éviter l'allocation de port HTTP, on a la possibilité de faire un reverse proxy. C’est à dire avoir un seul service frontal HTTP (appelons le frontal A) qui tourne sur le port 80 dont le seul et unique rôle est de distribuer les requêtes vers le bon conteneur HTTP de nos projets (par exemple en fonction du nom de domaine).

Il existe plusieurs reserve proxy dans docker. Tous fonctionnent de la même manière. Le conteneur du service de reserve proxy possède son propre réseau. Chaque projet est isolé dans son propre réseau également. Les conteneurs frontend HTTP de notre projet sont ajoutés dans le même réseau que notre reserve proxy HTTP. Grâce à des scripts de découverte automatique (au niveau du frontal A et par l’ajout de labels ou variables d’environnement sur les frontends de notre projet), le reverse proxy est capable de forwarder les requêtes sur le bon serveur frontend.

Deux reverses proxy fonctionnants très bien:

Voici un schéma de l’architecture cible:

TraefikTraefik
 

Exemple de service traefik (reverse proxy)

version: "3.7"

services:
traefik:
image: traefik
container_name: traefik:1.7
ports:
- '80:80'
- '443:443'
labels:
- 'traefik.enable=true'
- 'traefik.port=8080'
- 'traefik.frontend.rule=Host:traefik.local'
volumes:
- /var/run/docker.sock:/var/run/docker.sock

Utilisation du proxy traefik dans notre projet:

version: '3.7'

services:

http:
image: nginx:1.10.3
volumes:
- ./www:/usr/src/www
- ./my_default.conf:/etc/nginx/conf.d/default.conf
labels:
- 'traefik.enable=true'
- 'traefik.port=80'
- 'traefik.frontend.rule=Host:web.monprojet.local'
- 'traefik.docker.network=proxy-traefik_default'
networks:
- traefik
- default

php:
image: php:7.3.4-fpm
volumes:
- ./www:/usr/src/www

mysql:
image: mariadb:10.1.26
ports:
- 3306:3306
environment:
MYSQL_ROOT_PASSWORD: mon_super_mot_de_passe

redis:
image: redis:4
ports:
- 6379:6379

networks:
traefik:
external:
name: proxy-traefik_default

On notera la déclaration du réseau proxy-traefik_default pour pouvoir ajouter notre conteneur HTTP à ce réseau. Ensuite par l'utilisation de variable d'environnements traefik redirigera toutes requêtes du domaine web.monprojet.local vers le conteneur HTTP de notre projet. Il ne faut pas oublier d'ajouter le service HTTP sur le réseau traefik, mais également sur le réseau que docker va créer par défaut sans quoi le service HTTP ne pourrait pas joindre le service PHP.

Conclusion

On a vu que docker était un allié pour les développeurs permettant d'avoir un environnement au plus proche de l'infrastructure cible. Grâce à docker il est facile de valider des versions spécifiques. De plus docker apporte une facilité de partage et de déploiement d'environnement entre les différentes équipes (dev & ops particulièrement) via l'outil docker-compose notamment.

Pour les administrateurs systèmes, la conteneurisation est une alternative à la virtualisation, parfois lourde à mettre en oeuvre. C'est pourquoi on voit apparaître des solutions de type orchestrateur de conteneurs. Le rôle de celui-ci est de déployer et surveiller l'ensemble des conteneurs au sein d'une infrastructure. On citera notamment Kubernetes (leader dans le domaine) qui couvre une grande partie des besoins en entreprise. L'orchestrateur aura pour tâche de répartir automatique les conteneurs en fonction de la charge à l'instant t par exemple. Une très bonne connaissance de docker est nécessaire pour s'attaquer à ce type de solution, alors à vous de jouer !